Skip to content

Spine correctness, hardening and poll_host decomposition - #597

Closed
somethingwithproof wants to merge 82 commits into
Cacti:developfrom
somethingwithproof:consolidate/spine-batch
Closed

Spine correctness, hardening and poll_host decomposition#597
somethingwithproof wants to merge 82 commits into
Cacti:developfrom
somethingwithproof:consolidate/spine-batch

Conversation

@somethingwithproof

@somethingwithproof somethingwithproof commented Sep 3, 2026

Copy link
Copy Markdown
Member

Consolidates ten pull requests into one review. Closes #564, #565, #566, #567, #581, #582, #583, #587, #588, #590, #594.

They were opened separately and every pair of them conflicted on tests/unit/test_linked.c, because each appended to the same registration array. Whichever merged first would have forced a rebase on the other nine. One review, one merge, one approval.

Nothing is dropped and nothing is squashed: 20 commits, one logical change each, so the branch stays reviewable commit by commit.

Fixes

#581 — close-on-exec and bounded reap. PR #542 removed both while adding unrelated poller fixes; its branch predated #557 and the base looks folded in rather than rebased. An inherited pipe write end stops a script's reader seeing EOF, so the thread blocks to script_timeout for a device that answered. The unbounded waitpid has no timeout at all, and the thread holds its available_scripts token while it waits.

#564, #565, #566 — allocation checks, a log newline overflow, leaked result sets. Four calloc() results dereferenced without a check; spine_log() putting a newline's terminator one byte past a LOGSIZE buffer; four settings helpers returning without db_free_result() on the NULL-row branch.

#567get_date_format() rebuilt an invariant string, with a malloc/free pair, on every log line. Built once at config load instead.

#583 — results of 1024 bytes or more silently truncated to 1022. db_escape() sized its destination from the wrong buffer.

#582 — SNMPv3 authNoPriv and noAuthNoPriv non-functional since #373. Cacti stores the literal [None] for an unselected protocol; treating that as an error made two of the three security levels unusable. A device that polls under cmd.php could not poll under spine.

#588 — the p += snprintf idiom. snprintf returns the length it would have written, so the first truncation moves the cursor past the buffer and the next remainder underflows to a huge size_t. 53 sites. util.c:829-851 was already wrong in the stronger sense: it passed a fixed BUFSIZE to every call, so the bound never shrank.

#590 — remote pollers ignored dbonupdate and emitted the VALUES() upsert form MySQL deprecated in 8.0.20.

#594poll_host() skipped mysql_thread_end() when the device row was missing, leaking the client library's thread-local state once per affected device per cycle.

Refactors

poller_item_from_row() — 62 lines of column mapping in the middle of the result loop. Twenty-one optional columns, each with a default a NULL must not overwrite: a row missing snmp_port has to leave 161 in place rather than zero it. None of that was reachable.

#587poll_host() built the same six queries twice, 141 and 142 lines differing by 33. Collapsed onto two scope helpers, then extracted as poll_host_build_queries(). The reindex assert was written out three times, once per operator, each with 24 identical lines around a one-line comparison; it is now reindex_assert_failed().

poll_host() goes from 1,927 lines to 1,455, and from 840 executable lines under gcov to 790. That is a first cut, not a decomposition: the two blocks left inside it are still 697 and 308 lines.

#590 and #594 were both found by doing this. Each was a case of duplicated code that had quietly stopped agreeing with its other copy. That is the argument for the refactor, and it is why they are in the same PR rather than filed as unrelated fixes.

CI

A composite action for the build setup, and the unit tests run under ASan and UBSan.

Test infrastructure

tests/integration and tests/regression were in the tree and in no TESTS list. test_child_process_safety.sh had been failing on develop since #542 removed the close-on-exec code it guards. It found the regression at the time and nobody saw it, because nothing ran it.

All seven are in TESTS now. Each exits 77 where its fixture is missing, which automake reports as SKIP, so make check is meaningful on a machine without docker and complete in CI with it. In-tree: 14 targets, 10 pass, 4 skip.

Two build modes were missing:

  • --enable-tsan, because spine runs a thread per device over shared counters and a process-wide effective uid, and a data race here is the failure mode that costs the most to find any other way. It cannot be combined with --enable-sanitizers.
  • --enable-coverage, so the gcov flags live in one place, with make coverage to read the per-function numbers back.

All three of --enable-coverage, --enable-sanitizers and --enable-tsan build and pass.

Out-of-tree builds were broken by two relative paths: the regression script and the golden fixture lookup both assumed the source directory. make distcheck now passes. The golden test was also skipping silently when it could not find its fixture, which meant it passed under distcheck without running; it fails rather than skips now.

Tests

test_linked goes from 37 to 96, across 7 binaries and 14 test targets, all against the shipped objects rather than copies of the logic. Coverage of what was extracted, measured with gcov:

poller_item_from_row             100.00% of 54
poll_host_build_queries          100.00% of 29
reindex_assert_failed            100.00% of 14
poll_host_release                100.00% of 10
poll_host_release_connections    100.00% of 9
poller_item_scope                100.00% of 6
poller_owner_scope               100.00% of 6
spine_appendf                     87.50% of 16
db_escape                        100.00% of 11

The teardown pair reached 100% by interposing on its two sinks. db_release_connection() and mysql_thread_end() return nothing to assert on, so a separate test binary wraps both with ld --wrap and records what was called:

tests_unit_test_poll_host_release_LDFLAGS = \
    -Wl,--wrap=db_release_connection -Wl,--wrap=mysql_thread_end

That is worth noting beyond these two functions: most of what makes poll_host() unreachable is I/O with no observable result, and this is the cheaper way at it than extracting first.

spine_appendf sits at 87.5%. The two uncovered lines are the branch where vsnprintf returns negative on an encoding error. Reaching it portably is not possible without a libc-specific trick that would be fragile on another platform, so it is left uncovered and named rather than papered over.

Several tests fail against the old code on purpose, which is the point of them:

  • reverting the fcntl() calls fails test_cloexec_is_set_on_both_pipe_ends and test_pipe_is_not_inherited_across_exec, the second reporting 0 != 0x1, which is the child finding a descriptor it should not have
  • test_child_process_safety.sh still fails against develop's nft_popen.c, checked by running it against those files directly
  • test_old_idiom_overshoots_where_appendf_does_not runs the old expression and asserts the cursor lands outside the buffer
  • tests/golden/poll_host_queries.golden is diffed line by line by test_build_queries_matches_the_golden_capture, so any change to the generated SQL fails the suite with the first differing line

Two tests were removed: get_date_format() no longer returns storage the caller owns, so the tests asserting that it did are gone with the behaviour.

Verification

Clean ubuntu:24.04 matching ci.yml: builds with the same four warnings as develop, make check green on all six binaries, 90 of 90 tests.

One warning changed text without changing count. poller.c reports up to 4096 bytes where develop says up to 2047, because the #583 fix correctly doubled escaped_result to hold a fully escaped value. Worth a follow-up: result_string is sized RESULTS_BUFFER + SMALL_BUFSIZE, which a fully escaped result can now exceed. That is a pre-existing risk this fix makes visible, not a new one, and it is still an improvement on truncating at 1022.

Not included

PR Cacti#542 removed both while adding unrelated poller fixes; its branch predated
Cacti#557 and the base looks folded in rather than rebased. Nothing caught it: the
only guard was a shell script that grepped the source, and it was deleted in
the same commit.

An inherited pipe write end keeps a script's reader from seeing EOF, so the
thread blocks to script_timeout for a device that answered. The unbounded
waitpid has no timeout at all, and the thread holds its available_scripts token
while it waits.

The php.c reap survived Cacti#542 and is better than what was removed, so only its
pipes needed the flag. Both callers now share one helper, covered by six tests
against the shipped object: two of them fail if the close-on-exec goes away
again.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
php_processes, debug_devices and the two connection pools were dereferenced
on the next line without testing the allocation.

Closes Cacti#564

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
The two strncat() calls above it may fill flogmessage exactly, so the
unconditional strcat() put the terminator one byte past a LOGSIZE buffer.

Closes Cacti#565

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Closes Cacti#566

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
The format depends only on two settings read in read_config_options(), so
rebuilding it per log line cost a malloc/free pair and two switches.

Closes Cacti#567

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Adds a suite that includes util.c the way test_util_strings.c does, so the
date format caching, the settings-helper result release and the bounded
newline are exercised as shipped rather than as copies, plus an LD_PRELOAD
allocation-failure test for the two startup guards no unit test can reach.

test_linked freed the result of get_date_format() and expected that call to
clamp; both moved to set_date_format() with this change, so its assertions
move with them.

Changed-line coverage on this branch goes from 38% to 79%; the remainder
needs a live database and is noted in the pull request.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
db_escape() staged its input through a fixed DBL_BUFSIZE buffer before
escaping, so every caller was capped at 1022 input bytes regardless of the
destination it passed. A poller result of 1024 bytes or more was silently cut,
which matters because RESULTS_BUFFER defaults to 2048 and is tunable through
--with-results-buffer precisely to carry multi-value output. A cut result loses
its trailing field:value pair, so that data source gets a bad RRD update every
cycle.

The staging buffer also made the bound arithmetic wrong for a small
destination: with max_size 1, (trim_limit / 2) - 1 is -1, which snprintf takes
as an unbounded size, and the escaped result was then written into a
destination far smaller than it. Building the new tests against the old
implementation reproduces that as a glibc 'buffer overflow detected' abort.

Derive the limit from the destination instead, and escape straight from the
caller's input. poller.c sizes escaped_result at 2N+1 for its RESULTS_BUFFER
source, which is what the doubling requires.

Six cases added to test_linked, run against the shipped sql.o: metacharacter
escaping, a NULL input, a full RESULTS_BUFFER round trip, the 1022 to 2047
boundary the old staging buffer cut at, truncation into a destination too small
to hold the escaped form, and a degenerate max_size of 0 and 1.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Cacti stores the literal [None] when no SNMPv3 protocol is selected, and an
empty string for an absent passphrase. Since Cacti#373 spine treated both as errors,
so two of the three security levels stopped working while cmd.php kept
accepting them.

usm_lookup_auth_type([None]) returns -1, and the lookup returned 0 from the
session setup on any non-positive result, so a noAuthNoPriv device never opened
a session. Separately the authentication key was derived only on the privacy
path, so an authNoPriv session authenticated with securityAuthKeyLen still 0
and the agent answered with a USM authentication failure, which points the
operator at their credentials rather than at spine.

Decide the level first, from the four values Cacti supplies, and only require a
recognised protocol when authentication is actually in use. Derive the key
whenever the level authenticates rather than only when it also encrypts.

spine_snmpv3_security_level() is pure and covered by five cases in test_linked:
the [None] sentinel, each level, a protocol without its passphrase, and privacy
selected without authentication, which SNMPv3 does not offer and which must not
raise the level.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
snprintf returns the length it would have written, so the cursor moves past
the buffer on the first truncation and the next remainder underflows to a
huge size_t: an effectively unbounded size at a destination already out of
bounds.

util.c:829-851 was the worst of it. Every call there passed a fixed BUFSIZE
rather than a shrinking remainder, so the bound never moved, and one of them
interpolates set.host_id_list, which --hostlist can fill with 65534 bytes.

spine_appendf() owns the arithmetic and reports truncation. Converted all 53
sites, keeping each recompute line so no buffer depends on being initialised
in a new place.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
poller.c is the most frequently changed file in the tree and validate_result()
is where it decides whether a polled value is stored at all, yet neither it nor
is_multipart_output() had any coverage. A mistake in either silently discards
data or accepts a malformed multi-value string, and nothing would fail.

Six cases added to test_linked, so they run against the real poller.o rather
than a copy: numeric forms, null and junk, single and multi field output, the
space-to-delimiter balance that separates real multi-value output from prose
containing a colon, and the trim contract.

The trim case documents an asymmetry worth knowing. rtrim() writes a NUL over
the trailing run so it edits the caller's buffer, while ltrim() only advances a
pointer, so the leading run survives in the buffer the caller keeps. The trim
set also covers quotes and a backslash, not just whitespace. Written against
what the code does, after a first draft asserting the tidier behaviour failed.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Every build job repeated the same apt-get install and bootstrap/configure
pair, so a new dependency meant editing five places. The setup now lives in
.github/actions/build-spine.

mysql-server is dropped from the package set: configure only looks for
mysql.h and libmysqlclient, both from libmysqlclient-dev, and no job starts
or connects to a server.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
The sanitizer build only ran --version and --help, which exercises almost
none of the poller. make check is what drives the code, so it is what the
sanitizers should watch.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
poll_host() is 1,926 lines and builds the same six queries twice, once for the
main poller and once for a remote one. The bodies differ by 33 of 141 lines,
and every difference is the same rule: the main poller reads items that are not
deleted, a remote poller reads the items assigned to it.

Nothing in that construction was reachable from a test, so a column added to
one copy and not the other would not have been caught.

Extract the rule as poller_item_scope() and poller_owner_scope(), covered by
five cases in test_linked against the shipped poller.o: the deleted filter on
the main poller, the ownership filter on a remote one, the empty fragment the
main poller needs so callers can interpolate unconditionally, and a degenerate
buffer.

No call site changes yet. Collapsing the two branches onto these helpers is the
next step and is worth its own review, because the shipped query1 spells its
tail 'poller_id=%i' while the others spell it 'poller_id = %i', so the unified
text will normalise that.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
The two branches drifted: the remote copy never picked up the dbonupdate
handling the main copy grew, and nothing could have caught that because the
construction was not reachable from a test.

Captured what both branches emit across the vectors they switch on, collapsed
them onto poller_item_scope()/poller_owner_scope(), and diffed. Output is
byte-identical except query1 on a remote poller, which now spells its tail
'poller_id = N' rather than 'poller_id=N' to match the other five queries.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
set.dbonupdate is 1 on MySQL 8, which deprecated VALUES() in ON DUPLICATE KEY
UPDATE. The main poller switched to the row-alias form; the remote branch kept
the deprecated one because it had its own copy of the suffix. Closes Cacti#590.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
The construction was 167 lines in the middle of a 1,795-line function, so
nothing could reach it. It now takes its inputs as arguments and fills a
struct, which is what lets the golden capture become a test that runs instead
of a file with instructions attached.

poll_host is 1,620 lines, down from 1,927 on develop.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
poll_host() leaves through three places and each spelled its teardown out
again. The copies were not in the same order and did not hold the same steps:
the device-row-missing path never called mysql_thread_end(), so a device
deleted mid-cycle leaked the client library's thread-local state once per
cycle. Closes Cacti#594.

The two early exits now share poll_host_release(). The normal exit uses
error_string after the point where that helper frees it, so it shares only
poll_host_release_connections() and keeps its own ordering.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
The assert was written out three times inside poll_host(), once per operator,
each with the same twenty-four lines of logging and reindex queueing around a
one-line comparison. The three copies of the REPLACE INTO had drifted to three
different spellings of the same SQL.

Two details were buried in there and are now stated and covered: '=' compares
as text while '<' and '>' compare as numbers, and an assert_value of '0' never
fails a '<' assert, which is what stops a device with no recorded uptime from
looking like it rebooted.

The chain is 34 lines instead of 88. poll_host is 1,516 lines, down from 1,927
on develop.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Each branch appended to the same registration array, so every pair of them
conflicted there. Reconciled once: the additions from all seven, the
get_date_format changes from the log work, and the two tests it dropped
because that change makes get_date_format return storage it does not own.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
@somethingwithproof

Copy link
Copy Markdown
Member Author

Code-scanning overlap check: all 32 currently open alerts are cpp/overflowing-snprintf findings in util.c (lines 1045–1192 on develop). This PR’s #588 change replaces those executable p += snprintf(...) sites with spine_appendf, so I am not opening a duplicate security PR. After this branch is rebased and CodeQL reruns, we should verify that alerts 2502–2533 close automatically.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A few newly introduced safety/build mechanisms need tightening (notably append_hostrange() buffer-remaining guards at call sites and configure-time enforcement of incompatible sanitizer options) to avoid regressions and misconfigured builds.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR consolidates a set of correctness, hardening, and refactor commits across Spine’s poller, logging, SQL escaping, SNMPv3 session setup, child-process handling, and CI/test infrastructure, with an emphasis on making previously-unreachable paths testable and preventing silent drift between duplicated code paths.

Changes:

  • Hardens buffer handling and resource cleanup (bounded append helper, db_escape sizing/null behavior, poller push batching safety, logging newline bound, and improved teardown patterns).
  • Fixes SNMPv3 security-level handling (including [None] semantics) and strengthens child-process safety (CLOEXEC pipes, bounded reap, SIGPIPE handling).
  • Expands automated coverage (new unit/regression/integration tests, golden fixtures) and improves CI/build workflows (composite build action, sanitizer/coverage/tsan options, distcheck support).
File summaries
File Description
util.h Exposes set_date_format() and spine_appendf() for safer bounded string building.
util.c Fixes result leaks, log formatting caching, logging hardening, push batching safety, and adds spine_appendf() implementation.
sql.c Reworks db_escape() to size from destination and clear output on NULL input.
spine.h Exposes new poll_host-related helpers/types and sizes poller_output tuple buffers correctly.
spine.c Adds startup calloc() guards and uses spine_appendf() for safer SQL construction; fixes details allocation.
snmp.c Fixes SNMPv3 [None] handling, security-level logic, and passphrase wiping/freeing.
ping.c Refactors ping_icmp() to a single cleanup path and reduces privilege/lock hazards; adjusts init_sockaddr cleanup.
php.c Hardens script-server I/O and init teardown, CLOEXEC pipe creation, FD_SETSIZE guard, and restart recursion control.
nft_popen.h Declares new cloexec/pipe/reap/abandoned-child APIs for child safety.
nft_popen.c Implements CLOEXEC pipes, bounded reap with escalation, abandoned-child sweeping, and safer ownership in nft_pclose().
error.c Stops treating SIGPIPE as fatal and explicitly ignores/restores SIGPIPE in handler install/uninstall.
Makefile.am Wires in new tests (including wrapped-link tests when supported) and adds coverage target + shell tests.
tests/unit/Makefile Adds new unit test binaries to the unit-test build target list.
tests/unit/test_util_paths.c Unit coverage for util.c fixes (date-format caching, newline bound, NULL-row frees).
tests/unit/test_snmpv3_session.c Unit tests SNMPv3 session securityLevel construction/refusal behavior.
tests/unit/test_poller_output.c Tests tuple formatting/escaping and buffer sizing behavior for poller_output rows.
tests/unit/test_poll_host_release.c Tests poll_host teardown correctness (mysql_thread_end + connection release) via ld --wrap.
tests/unit/test_ping_icmp.c Exercises ping_icmp() cleanup and retry/FD_SETSIZE behaviors (skips where raw ICMP unavailable).
tests/unit/test_log_newline_bound.c Regression guard for bounded newline append behavior.
tests/regression/test_remote_push.sh Structural guard for remote push invariants and batching/flush correctness.
tests/regression/test_child_process_safety.sh Structural guard for CLOEXEC, bounded reap, ping_icmp cleanup shape, and poll_host cleanup invariants.
tests/integration/smoke_test.sh Adds SPINE_INTEGRATION/docker gating so distcheck/make check skip cleanly without fixtures.
tests/integration/test_output_regex.sh Adds SPINE_INTEGRATION/docker gating so distcheck/make check skip cleanly without fixtures.
tests/integration/test_db_column_detect.sh Adds SPINE_INTEGRATION/docker gating so distcheck/make check skip cleanly without fixtures.
tests/integration/test_alloc_failure.sh Adds LD_PRELOAD-based integration test for startup allocation failure guards (skips when not applicable).
tests/golden/README.md Documents golden SQL capture workflow and update rules.
tests/golden/poll_host_queries.golden Captures expected poll_host query construction across key input combinations.
configure.ac Adds --enable-tsan, --enable-coverage, ld --wrap probing, and DES symbol probing for portability.
CHANGELOG Documents the set of fixes/hardening changes included in the PR.
.gitignore Ignores automake artifacts, coverage outputs, dist tarballs, and unit test binaries.
.github/workflows/ci.yml Switches to composite build action; runs unit tests under sanitizers; adds privileged ping tests.
.github/workflows/integration.yml Pins checkout action and hardens shell steps with pipefail.
.github/workflows/fuzz.yml Uses composite build action and pins artifact upload action.
.github/actions/build-spine/action.yml New composite action to install deps, bootstrap, and configure consistently across workflows.
Review details
  • Files reviewed: 35/36 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread spine.c
Comment thread util.c
Comment thread configure.ac
Comment thread util.h Outdated
@somethingwithproof
somethingwithproof force-pushed the consolidate/spine-batch branch 4 times, most recently from 5d1bc9b to e130610 Compare September 5, 2026 03:14
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
@somethingwithproof

Copy link
Copy Markdown
Member Author

Reorganization status: the reviewable pieces have been extracted into focused PRs with independent tests and rollback boundaries.

Ready/open:

All Copilot inline threads across #597, #614, #617, #618, #619, #620, and #621 are resolved. Fresh Copilot rereviews have been requested after the latest fixes on #618, #619, #620, and #621.

Remaining dependency-gated slices:

This PR remains the umbrella/accounting record; it should not be merged as a second copy of the extracted changes.

somethingwithproof added a commit to somethingwithproof/spine that referenced this pull request Sep 6, 2026
Collect the child-process corrections from Cacti#597 after the foundational close-on-exec restoration. Preserve cancellation-safe abandoned-child handling, descriptor failure contracts, PHP resource ownership, startup restart bounds, and persistent SIGPIPE handling.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
somethingwithproof added a commit to somethingwithproof/spine that referenced this pull request Sep 6, 2026
Carry the PHP and signal-handling review fixes from Cacti#597 while leaving nft_pclose ownership to focused PR Cacti#614. The remaining registry-specific review changes will be replayed only after Cacti#614 lands.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
somethingwithproof added a commit to somethingwithproof/spine that referenced this pull request Sep 6, 2026
Extract the production-linked PHP runtime suite, its script-server fixture, the portable linker-wrap probe, and the structural regression guard from Cacti#597.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
somethingwithproof added a commit to somethingwithproof/spine that referenced this pull request Sep 6, 2026
Share the reviewed spawn-attribute helper between nft_popen and the PHP script server without pulling the overlapping nft_pclose ownership rewrite from Cacti#597.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
@somethingwithproof

Copy link
Copy Markdown
Member Author

Superseded by narrower, independently reviewable PRs to reduce review bandwidth:

The remaining core, SQL, and poll-host work is preserved on the existing topic branches and deliberately deferred rather than dropped. Closing this umbrella removes duplicate review surface; its branch and commits remain recoverable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unchecked calloc() results in spine.c dereferenced immediately after allocation

3 participants